agentmux_srv\backend\history/
index.rs1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Mutex;
9
10use super::adapter::*;
11
12fn default_isolated_roots() -> Vec<PathBuf> {
17 let shared = std::env::var_os("AGENTMUX_SHARED_DIR")
18 .map(PathBuf::from)
19 .or_else(|| dirs::home_dir().map(|h| h.join(".agentmux").join("shared")));
20 match shared {
21 Some(s) => vec![s.join("providers"), s.join("identities")],
22 None => Vec::new(),
23 }
24}
25
26pub struct SessionIndex {
28 sessions: Mutex<HashMap<String, SessionMeta>>,
30 adapters: Vec<Box<dyn HistoryAdapter>>,
32 isolated_roots: Vec<PathBuf>,
34}
35
36impl SessionIndex {
37 pub fn new(adapters: Vec<Box<dyn HistoryAdapter>>) -> Self {
38 Self::with_isolated_roots(adapters, default_isolated_roots())
39 }
40
41 pub fn with_isolated_roots(
43 adapters: Vec<Box<dyn HistoryAdapter>>,
44 isolated_roots: Vec<PathBuf>,
45 ) -> Self {
46 SessionIndex {
47 sessions: Mutex::new(HashMap::new()),
48 adapters,
49 isolated_roots,
50 }
51 }
52
53 fn is_isolated(&self, path: &Path) -> bool {
56 self.isolated_roots.iter().any(|r| path.starts_with(r))
57 }
58
59 pub fn refresh(&self) -> (u32, u32, u32) {
62 let mut discovered: u32 = 0;
63 let mut updated: u32 = 0;
64 let mut new_count: u32 = 0;
65
66 let mut new_sessions: HashMap<String, SessionMeta> = HashMap::new();
67
68 for adapter in &self.adapters {
69 let files = match adapter.discover_files() {
70 Ok(f) => f,
71 Err(e) => {
72 tracing::warn!(
73 "history: failed to discover {} files: {}",
74 adapter.provider(),
75 e
76 );
77 continue;
78 }
79 };
80
81 discovered += files.len() as u32;
82
83 for file in &files {
84 match adapter.extract_meta(&file.file_path) {
85 Ok(Some(meta)) => {
86 new_sessions.insert(meta.session_id.clone(), meta);
87 }
88 Ok(None) => {} Err(e) => {
90 tracing::debug!(
91 "history: failed to extract meta from {}: {}",
92 file.file_path,
93 e
94 );
95 }
96 }
97 }
98 }
99
100 let mut sessions = self.sessions.lock().unwrap();
102 for (id, _meta) in &new_sessions {
103 if sessions.contains_key(id) {
104 updated += 1;
105 } else {
106 new_count += 1;
107 }
108 }
109
110 *sessions = new_sessions;
111
112 (discovered, updated, new_count)
113 }
114
115 pub fn list(
117 &self,
118 provider: Option<&str>,
119 project: Option<&str>,
120 offset: usize,
121 limit: usize,
122 sort_by: &str,
123 sort_dir: &str,
124 ) -> (Vec<SessionMeta>, u32, bool) {
125 let sessions = self.sessions.lock().unwrap();
126
127 let mut filtered: Vec<&SessionMeta> = sessions
128 .values()
129 .filter(|s| {
130 if let Some(p) = provider {
131 if s.provider != p {
132 return false;
133 }
134 }
135 if let Some(proj) = project {
136 if !s.working_directory.contains(proj) {
137 return false;
138 }
139 }
140 true
141 })
142 .collect();
143
144 let desc = sort_dir != "asc";
146 match sort_by {
147 "created_at" | "created" => {
148 filtered.sort_by(|a, b| {
149 if desc {
150 b.created_at.cmp(&a.created_at)
151 } else {
152 a.created_at.cmp(&b.created_at)
153 }
154 });
155 }
156 "messages" => {
157 filtered.sort_by(|a, b| {
158 if desc {
159 b.message_count.cmp(&a.message_count)
160 } else {
161 a.message_count.cmp(&b.message_count)
162 }
163 });
164 }
165 "tokens" => {
166 filtered.sort_by(|a, b| {
167 if desc {
168 b.total_tokens.cmp(&a.total_tokens)
169 } else {
170 a.total_tokens.cmp(&b.total_tokens)
171 }
172 });
173 }
174 _ => {
175 filtered.sort_by(|a, b| {
177 if desc {
178 b.modified_at.cmp(&a.modified_at)
179 } else {
180 a.modified_at.cmp(&b.modified_at)
181 }
182 });
183 }
184 }
185
186 let total = filtered.len() as u32;
187 let has_more = offset + limit < filtered.len();
188 let page: Vec<SessionMeta> = filtered
189 .into_iter()
190 .skip(offset)
191 .take(limit)
192 .cloned()
193 .collect();
194
195 (page, total, has_more)
196 }
197
198 pub fn get_meta(&self, session_id: &str) -> Option<SessionMeta> {
200 let sessions = self.sessions.lock().unwrap();
201 sessions.get(session_id).cloned()
202 }
203
204 pub fn get_full(&self, session_id: &str) -> Result<Option<HistorySession>, HistoryError> {
206 let meta = match self.get_meta(session_id) {
207 Some(m) => m,
208 None => return Ok(None),
209 };
210
211 for adapter in &self.adapters {
213 if adapter.provider() == meta.provider {
214 return adapter.parse_file(&meta.file_path);
215 }
216 }
217
218 Err(HistoryError::Other(format!(
219 "no adapter for provider: {}",
220 meta.provider
221 )))
222 }
223
224 pub fn is_empty(&self) -> bool {
226 self.sessions.lock().unwrap().is_empty()
227 }
228
229 pub fn delete(&self, session_id: &str) -> Result<bool, HistoryError> {
234 let meta = match self.get_meta(session_id) {
235 Some(m) => m,
236 None => return Ok(false),
237 };
238 let path = Path::new(&meta.file_path);
239 if !self.is_isolated(path) {
242 return Err(HistoryError::Other(format!(
243 "refusing to delete '{}' — it lives in your personal {} home, not AgentMux's",
244 meta.file_path, meta.provider
245 )));
246 }
247 match std::fs::remove_file(path) {
251 Ok(()) => {}
252 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
253 Err(e) => return Err(e.into()),
254 }
255 if let Some(parent) = path.parent() {
258 let sidecar = parent.join(session_id);
259 if sidecar.is_dir() {
260 let _ = std::fs::remove_dir_all(&sidecar);
261 }
262 }
263 self.sessions.lock().unwrap().remove(session_id);
264 Ok(true)
265 }
266
267 pub fn clear(&self, provider: Option<&str>, project: Option<&str>) -> u32 {
270 let ids: Vec<String> = {
273 let sessions = self.sessions.lock().unwrap();
274 sessions
275 .values()
276 .filter(|s| provider.map_or(true, |p| s.provider == p))
277 .filter(|s| project.map_or(true, |proj| s.working_directory.contains(proj)))
278 .filter(|s| self.is_isolated(Path::new(&s.file_path)))
280 .map(|s| s.session_id.clone())
281 .collect()
282 };
283 let mut deleted = 0;
284 for id in ids {
285 if matches!(self.delete(&id), Ok(true)) {
286 deleted += 1;
287 }
288 }
289 deleted
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use std::fs;
297 use std::path::PathBuf;
298
299 struct MockAdapter {
302 files: Vec<DiscoveredFile>,
303 provider: String,
304 working_directory: String,
305 }
306 impl HistoryAdapter for MockAdapter {
307 fn provider(&self) -> &str {
308 &self.provider
309 }
310 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
311 Ok(self
312 .files
313 .iter()
314 .map(|f| DiscoveredFile { file_path: f.file_path.clone(), mtime_ms: f.mtime_ms })
315 .collect())
316 }
317 fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
318 let id = PathBuf::from(file_path)
319 .file_stem()
320 .map(|s| s.to_string_lossy().into_owned())
321 .unwrap_or_default();
322 Ok(Some(SessionMeta {
323 session_id: id,
324 file_path: file_path.to_string(),
325 provider: self.provider.clone(),
326 model: String::new(),
327 slug: String::new(),
328 working_directory: self.working_directory.clone(),
329 created_at: 0,
330 modified_at: 0,
331 message_count: 0,
332 first_user_message: String::new(),
333 file_size_bytes: 0,
334 git_branch: String::new(),
335 total_tokens: 0,
336 subagent_count: 0,
337 }))
338 }
339 fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
340 Ok(None)
341 }
342 }
343
344 fn write_session(dir: &std::path::Path, id: &str) -> String {
345 let f = dir.join(format!("{id}.jsonl"));
346 fs::write(&f, b"{}").unwrap();
347 f.to_string_lossy().into_owned()
348 }
349
350 #[test]
351 fn delete_removes_file_and_index_entry() {
352 let dir = std::env::temp_dir().join(format!("amux-hist-del-{}", std::process::id()));
353 fs::create_dir_all(&dir).unwrap();
354 let fp = write_session(&dir, "sess-abc");
355 let sidecar = dir.join("sess-abc");
357 fs::create_dir_all(sidecar.join("subagents")).unwrap();
358
359 let idx = SessionIndex::with_isolated_roots(
360 vec![Box::new(MockAdapter {
361 files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
362 provider: "mock".into(),
363 working_directory: "/proj".into(),
364 })],
365 vec![dir.clone()],
366 );
367 idx.refresh();
368 assert!(idx.get_meta("sess-abc").is_some());
369
370 assert!(matches!(idx.delete("sess-abc"), Ok(true)));
371 assert!(!std::path::Path::new(&fp).exists(), "transcript removed");
372 assert!(!sidecar.exists(), "subagents sidecar removed");
373 assert!(idx.get_meta("sess-abc").is_none(), "dropped from index");
374 assert!(matches!(idx.delete("sess-abc"), Ok(false)), "second delete is a no-op");
375
376 let _ = fs::remove_dir_all(&dir);
377 }
378
379 #[test]
380 fn clear_respects_provider_filter() {
381 let dir = std::env::temp_dir().join(format!("amux-hist-clr-{}", std::process::id()));
382 fs::create_dir_all(&dir).unwrap();
383 let a = write_session(&dir, "a");
384 let b = write_session(&dir, "b");
385
386 let idx = SessionIndex::with_isolated_roots(
387 vec![Box::new(MockAdapter {
388 files: vec![
389 DiscoveredFile { file_path: a.clone(), mtime_ms: 0 },
390 DiscoveredFile { file_path: b.clone(), mtime_ms: 0 },
391 ],
392 provider: "mock".into(),
393 working_directory: "/proj".into(),
394 })],
395 vec![dir.clone()],
396 );
397 idx.refresh();
398
399 assert_eq!(idx.clear(Some("other"), None), 0);
401 assert!(std::path::Path::new(&a).exists());
402 assert_eq!(idx.clear(Some("mock"), None), 2);
403 assert!(!std::path::Path::new(&a).exists() && !std::path::Path::new(&b).exists());
404 assert!(idx.is_empty());
405
406 let _ = fs::remove_dir_all(&dir);
407 }
408
409 #[test]
410 fn refuses_to_delete_outside_isolated_roots() {
411 let dir = std::env::temp_dir().join(format!("amux-hist-guard-{}", std::process::id()));
414 fs::create_dir_all(&dir).unwrap();
415 let fp = write_session(&dir, "personal-sess");
416
417 let idx = SessionIndex::with_isolated_roots(
418 vec![Box::new(MockAdapter {
419 files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
420 provider: "claude".into(),
421 working_directory: "/home/me/.claude".into(),
422 })],
423 vec![std::env::temp_dir().join("amux-some-other-isolated-root")],
425 );
426 idx.refresh();
427
428 assert!(idx.delete("personal-sess").is_err(), "single delete must refuse");
429 assert!(std::path::Path::new(&fp).exists(), "file must be intact");
430 assert_eq!(idx.clear(None, None), 0, "clear-all must skip it");
431 assert!(std::path::Path::new(&fp).exists(), "still intact after clear");
432
433 let _ = fs::remove_dir_all(&dir);
434 }
435}